Skip to content

[5.x] Make MCP upload URLs work over stdio and on control panel endpoints - #20468

Open
brandonkelly wants to merge 3 commits into
5.xfrom
feature/mcp-stdio-upload-urls
Open

brandonkelly wants to merge 3 commits into
5.xfrom
feature/mcp-stdio-upload-urls

Conversation

@brandonkelly

@brandonkelly brandonkelly commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Why craftcom local had no assets_upload_url: its MCP server is still on the 5.12.0.1 release code. That assets_create only takes {download_url, file_id} ("temporary HTTPS file reference"), and tools like user_permissions_user_update are also missing. craftcom staging runs current 5.x, which includes #20377 (base64 data, stdio path, and assets_upload_url). Updating craftcom's local craftcms/cms brings the upload-URL flow back.

This PR fixes two remaining gaps so upload URLs work wherever assets_create does:

                                          before            after
stdio server (craft mcp/serve)            tool not offered  offered; URL from the site's base URL
HTTP, endpoint in the control panel       PUT 404s          reaches the upload action
HTTP, endpoint on the front end           works             unchanged

1. Stdio. assets_upload_url was #[RequiresHttp]. Over stdio, the only way to send a large local file was path, and that only works when the server can read the file inside its own project. That fails when the server runs in a container (for example ddev craft mcp/serve) or the file is elsewhere. Console requests can't build the HTTP endpoint's URL: UrlHelper drops the host and subdirectory, producing /index.php?p=… or https://example.test/index.php?p={cpTrigger}/mcp for a site at https://example.test/craft/. So over stdio the URL is built from the site's base URL, and the token routes it to mcp/upload whatever the path. If that base URL isn't absolute, the tool fails before reserving anything.

 #[McpTool(name: 'assets_upload_url', ...)]
-#[RequiresHttp]
 public function uploadUrl(): array
 {
+    if (!Craft::$app->getRequest()->getIsConsoleRequest()) {
         return createUpload(user, settings->url(settings->route));
+    }
+    baseUrl = UrlHelper::baseSiteUrl()   // must be absolute
+    return createUpload(user, baseUrl);
 }

2. Control panel endpoints. Upload URLs use the authenticated server's endpoint, which defaults to /{cpTrigger}/mcp when public registration is off. McpController::beforeAction() restricts every action except authenticated to the front end. That rule is meant for the public server and discovery, but it also 404ed upload on control panel requests. upload now joins authenticated. It is still authorized only by its single-use token.

-if ($action->id !== 'authenticated' && (cp request || cp host request)) {
+if (!in_array($action->id, ['authenticated', 'upload'], true) && (cp request || cp host request)) {
     throw new NotFoundHttpException();

The token, single use, 15-minute expiry, saveAssets check, and per-user claim are unchanged.

Craft 6: craftcms/cms 6.x has a different design. assets_upload_prepare is a resumable upload authenticated with the MCP bearer token, deliberately #[RequiresHttp], and its description directs stdio clients to send the file inline. So the stdio change doesn't port directly; whether Craft 6 should offer a stdio upload path is a separate decision. The control panel 404 is specific to Craft 5's McpController.

Limitation: over stdio, the upload goes to the site's base URL. On a headless site whose base URL points at a separate front-end app, that won't reach Craft.

Evidence

AssetFieldUploadTest::testUploadUrlsWorkOverStdio (omitScriptNameInUrls: false):

site base URL "/"                          -> assets_upload_url fails: "…require the site to have an absolute base URL."
site base URL https://example.test/craft/  -> upload_url starts with https://example.test/craft/?, token routes to mcp/upload
receiveUpload + consumeFile({upload_id})   -> same bytes as the file
  • Before: Tool not found: "assets_upload_url". With only #[RequiresHttp] removed, the root-relative case returns a relative URL (Failed asserting that false is true), and the subdirectory case returns https://example.test/index.php?p={cpTrigger}/mcp&token=….
    After: OK (6 tests, 22 assertions)

McpServersCest::testUploadUrlsWorkOnControlPanelEndpoints: PUT to /{cpTrigger}/mcp?token=<mcp/upload token>

  • Before: Expected HTTP Status Code: 400 (Bad Request). Actual Status Code: 404 (Not Found), thrown at McpController.php:58
    After: 400 from the upload action, which rejects the unreserved upload. McpServersCest passes (9 tests).

vendor/bin/codecept run unit mcp passes (71 tests). ECS and Rector are clean. PHPStan passes (pre-commit hook).

The stdio server can only read local paths within its own project, so
agents whose files it can't see, such as when it runs in a container,
had no way to upload a large local file. Upload URLs point at the site's
web server, so they work for stdio sessions too.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8
Copilot AI balanced review requested due to automatic review settings November 7, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Stdio upload URL generation can drop the site subdirectory or return a hostless relative URL, preventing clients from reaching the upload endpoint.

2 open issues
What changed in this PR

This PR exposes assets_upload_url over stdio so MCP clients can upload files that the server cannot read directly, including files outside a container.

Changes:

  • Removes the upload-URL tool’s HTTP-only restriction.
  • Updates server instructions and release notes to describe stdio support.
  • Adds a stdio regression test covering URL generation, token routing, and uploaded-file consumption.
File Description
tests/​unit/​mcp/​AssetFieldUploadTest.php Adds stdio upload-URL coverage and generalizes the tool-call helper.
src/​mcp/​capabilities/​Assets.php Makes assets_upload_url available over stdio.
src/​mcp/​AdminInstructions.php Documents upload URLs for files inaccessible by server-local paths.
CHANGELOG.md Announces stdio upload-URL support.

Review effort: Balanced

description: 'Gets a single-use URL for uploading a local or large file. Send the raw file bytes as the body of a PUT request to upload_url before expires_at (for example, curl -T ./photo.jpg "<upload_url>"), then pass {"upload_id": "...", "file_name": "photo.jpg"} as the file for assets_create or assets_replace. The file name is required, since it can’t be derived from the upload. Requires permission to save assets in at least one volume.',
)]
#[RequiresHttp]
public function uploadUrl(): array

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da4eb51. Over stdio, upload URLs now use the site's base URL, which keeps the host and subdirectory. The token routes the upload whatever the path. The test now runs with omitScriptNameInUrls: false and a https://example.test/craft/ base URL. The old code produced https://example.test/index.php?p={cpTrigger}/mcp&token=… there.

description: 'Gets a single-use URL for uploading a local or large file. Send the raw file bytes as the body of a PUT request to upload_url before expires_at (for example, curl -T ./photo.jpg "<upload_url>"), then pass {"upload_id": "...", "file_name": "photo.jpg"} as the file for assets_create or assets_replace. The file name is required, since it can’t be derived from the upload. Requires permission to save assets in at least one volume.',
)]
#[RequiresHttp]
public function uploadUrl(): array

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da4eb51. Over stdio, assets_upload_url now fails before reserving anything when the site's base URL isn't absolute: "Upload URLs over stdio require the site to have an absolute base URL." The test covers a / base URL.

Over stdio, console URLs lacked the host and the site's subdirectory, or
were relative, so upload URLs now use the site's base URL and the tool
fails clearly when it isn't absolute. Over HTTP, upload URLs for an
authenticated endpoint within the control panel 404ed, because the
upload action was limited to the front end along with the public server.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8
@brandonkelly brandonkelly changed the title [5.x] Offer assets_upload_url over stdio [5.x] Make MCP upload URLs work over stdio and on control panel endpoints Nov 7, 2026
Tokenized URLs' scheme follows useSslOnTokenizedUrls, so only check the
upload URL's host and path.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-c93e4e62-8dbe-4153-b265-a57d6fb57ac8

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants